---
# System prepended metadata

title: PARTICLE ANIMATION

---

# PARTICLE ANIMATION

**ParticleAnimation.kt**
```kotlin
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import com.annhienktuit.domain.models.EPointF
import com.annhienktuit.muzikv2.R
import com.annhienktuit.muzikv2.enums.Interpolation
import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.painters.Painter
import com.annhienktuit.muzikv2.utils.VisualizerHelper
import com.annhienktuit.muzikv2.utils.audiovisualizer.PolyBezierPathUtil
import com.annhienktuit.muzikv2.utils.maths.polynomials.PolynomialSplineFunction
import java.util.*
import kotlin.math.PI

/**
 *Created by Nhien Nguyen on 9/26/2022
 */
class ParticleAnimation(
    var context: Context,
    override var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG),
    private var startHz: Int = 20,
    private var endHz: Int = 2000,
    private var interpolator: Interpolation = Interpolation.SPLINE,
) : Painter() {
    private var upperGravityPoints = Array(0) { GravityModel(0f) }
    private val barWidth =
        context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat()
    private val radiusR: Float = .4f
    private val ampR: Float = 1f //The rate of amplification
    private val angleOffset = 2 * PI.toFloat() / numberOfDot
    private var shortest = 0F
    override var discRadius = 0
        set(value) {
            field = value
            shortest = getShortest()
            adjustRadius(shortest)
        }
    override var isPlaying = false
    var firstTimeRunning = true
    private var dotCoordinate = Pair(0F, 0F)
    private var currentHeight = 0
    private var currentWidth = 0
    lateinit var upperFFT: FloatArray
    var upperCurveUtil: PolynomialSplineFunction? = null

    // Fields for normalization
    private var min = 0f
    private var max = 0f
    private val highFreqDelta = 20
    private var frequencyMap = HashMap<Int, Int>()
    private var random = Random()

    //Fields for bottom animator
    private var bottomLinePaint = Paint(Paint.ANTI_ALIAS_FLAG)
    lateinit var bottomFFT: FloatArray
    private var polyPathsUtil = PolyBezierPathUtil()
    var bottomCurveUtil: PolynomialSplineFunction? = null
    private var bottomStartHZ = 8000
    private var bottomEndHz = 10000
    private var bottomGravityPoints = Array(0) { GravityModel(0f) }

    //Fields for spears
    private var spearPaint = Paint(Paint.ANTI_ALIAS_FLAG)

    init {
        paint.apply {
            color = Color.WHITE
            style = Paint.Style.FILL
            strokeWidth = barWidth
            strokeCap = Paint.Cap.ROUND
        }
        bottomLinePaint.apply {
            color = Color.WHITE
            style = Paint.Style.STROKE
            strokeWidth = barWidth / 2
            strokeCap = Paint.Cap.ROUND
        }
        spearPaint.apply {
            color = Color.WHITE
            style = Paint.Style.FILL
            strokeWidth = barWidth / 2
            strokeCap = Paint.Cap.ROUND
            alpha = 20
        }
    }

    override fun calc(helper: VisualizerHelper) {
        calcForUpperAnimator(helper)
        calcForBottomAnimator(helper)
    }

    private fun calcForBottomAnimator(helper: VisualizerHelper) {
        bottomFFT = (helper.getFftMagnitudeRange(bottomStartHZ, bottomEndHz))
        bottomFFT = getCircleFft(bottomFFT)
        if (bottomGravityPoints.size != bottomFFT.size) {
            bottomGravityPoints = Array(bottomFFT.size) {
                GravityModel(0F)
            }
        }
        bottomGravityPoints.forEachIndexed { index, bar ->
            bar.update(bottomFFT[index] * ampR)
        }
        bottomCurveUtil = interpolateFft(bottomGravityPoints, numberOfDot / 5, interpolator)
    }

    private fun calcForUpperAnimator(helper: VisualizerHelper) {
        upperFFT = helper.getFftMagnitudeRange(startHz, endHz)
        upperFFT = getPowerFft(upperFFT)
        upperFFT = if (firstTimeRunning) {
            getCircleFft(upperFFT, true)
        } else {
            getCircleFft(upperFFT)
        }
        minMaxNormalize(upperFFT, visualizerMaxHeight, 0)
        if (upperGravityPoints.size != upperFFT.size) upperGravityPoints = Array(upperFFT.size) {
            GravityModel(
                0f
            )
        }
        upperGravityPoints.forEachIndexed { index, bar -> bar.update(upperFFT[index] * ampR) }
        upperCurveUtil = interpolateFftCircle(upperGravityPoints, numberOfDot, interpolator)
    }

    override fun draw(canvas: Canvas, width: Int, height: Int, drawWithoutCalc: Boolean) {
        if (drawWithoutCalc) {
            drawHelperCircleLine(canvas, .5f, .5f) {
                // Still need to run this function to do the translate, but we don't update data on the first time
                // Otherwise, it will draw in somewhere psychically
                //canvas.drawLines(points, paint)
            }
            return
        }
        currentWidth = width
        currentHeight = height
        if (firstTimeRunning) {
            shortest = Integer.min(width, height).toFloat()
            adjustRadius(shortest)
        }
        drawHelperCircleLine(canvas, .5f, .5f) {
            if (!firstTimeRunning || !drawWithoutCalc) {
                for (idx in 0 until numberOfDot) {
                    //TODO: Replace these magic numbers
                    upperPoints[idx] = toCartesianEpointF(
                        shortest / 2f * (radiusR + 0.03F) + upperCurveUtil?.value(idx.toDouble())!!
                            .toFloat(), angleOffset * idx
                    )
                    bottomPoints[idx] = toCartesianEpointF(
                        shortest / 2.4f * (radiusR + 0.03F) + bottomCurveUtil?.value(idx.toDouble())!!
                            .toFloat(), angleOffset * idx
                    )
                }
            }
        }
        upperPoints[numberOfDot] = upperPoints[0]
        bottomPoints[numberOfDot] = bottomPoints[0] // we do this to connect the begin-end of line
        for (idx in 0 until upperPoints.size) {
            canvas.drawCircle(upperPoints[idx].x, upperPoints[idx].y, 4f, paint)
        }
        canvas.drawPath(
            polyPathsUtil.computePathThroughKnots(bottomPoints.asList()), bottomLinePaint
        )
        drawSpears(canvas)
        if (firstTimeRunning) firstTimeRunning = false
    }

    private fun drawSpears(canvas: Canvas) {
        for (idx in bottomPoints.indices) {
            if (upperPoints[idx].distanceFrom(bottomPoints[idx]) > drawSpearThreshold) {
                canvas.drawLine(
                    upperPoints[idx].x,
                    upperPoints[idx].y,
                    (bottomPoints[idx].x + upperPoints[idx].x) / 2,
                    (bottomPoints[idx].y + upperPoints[idx].y) / 2,
                    spearPaint
                )
            }
        }
    }

    /**
     * Calculate the new radius between 2 opposite point in circle
     * Then scale [shortest] based on ratio between new radius and old radius
     */
    private fun adjustRadius(minValue: Float) {
        val firstPoint = toCartesian(minValue / 2f * radiusR, angleOffset * 0)
        val secondPoint = toCartesian(minValue / 2f * radiusR, angleOffset * (numberOfDot / 2))
        val currentRadius = (firstPoint.first - secondPoint.first) / 2
        val newRadius: Float
        if (currentRadius < discRadius) {
            newRadius = discRadius.toFloat() + discMargin
            val ratio = (newRadius / currentRadius)
            this.shortest *= ratio
        }
    }

    /**
     * Apply min-max normalization then handle if its value is "high frequency" continuously
     * @see <a href="https://towardsdatascience.com/everything-you-need-to-know-about-min-max-normalization-in-python-b79592732b79">Min-Max Normalization </a> <br>
     * <p> A data is defined to be "high frequency" if its value is in range <br> [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]] </p>
     */
    private fun minMaxNormalize(fftData: FloatArray, maxLimit: Int, minLimit: Int = 0) {
        min = Float.MAX_VALUE
        max = Float.MIN_VALUE
        for (value in fftData) {
            if (value > max) max = value
            if (value < min) min = value
        }
        if (max > visualizerMaxHeight) {
            for (idx in fftData.indices) {
                fftData[idx] = ((fftData[idx] - min) / (max - min)) * maxLimit + minLimit
                if (fftData[idx].isExclusive(
                        visualizerMaxHeight - highFreqDelta, visualizerMaxHeight
                    )
                ) {
                    handleHighFreqValue(idx)
                } else {
                    resetMapValue(idx)
                }
            }
        }
    }

    /**
     * Handle high frequency value to avoid low variability
     * If that data is in range [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]]
     */
    private fun handleHighFreqValue(idx: Int) {
        try {
            if (frequencyMap.containsKey(idx)) {
                frequencyMap[idx] = frequencyMap[idx]!!.plus(1)
            } else {
                frequencyMap[idx] = 1
            }
            if (frequencyMap[idx]!! > 3) {
                frequencyMap[idx] = 0
                upperFFT[idx] =
                    (random.nextInt((visualizerMaxHeight - highFreqDelta) - highFreqDelta + 1) + highFreqDelta).toFloat()
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    /**
     * If the frequency value is not continue to return high frequency, reset it counter in [frequencyMap]
     */
    private fun resetMapValue(idx: Int) {
        if (frequencyMap.containsKey(idx) && frequencyMap[idx]!! > 0) {
            frequencyMap[idx] = 0
        }
    }

    private fun Float.isExclusive(start: Int, stop: Int): Boolean {
        return (this >= start && this <= stop)
    }

    private fun getShortest(): Float = Integer.min(currentWidth, currentHeight).toFloat()

    private fun FloatArray.contentToStringWithIndex(): String {
        val strBuilder = StringBuilder()
        strBuilder.append("[")
        for (index in 0 until this.size) {
            strBuilder.append(" $index: ${this[index]},")
        }
        strBuilder.append("]")
        return strBuilder.toString()
    }

    companion object {
        private const val TAG = "ParticleAnimation"
        private const val numberOfDot: Int = 100
        private const val drawSpearThreshold = 20F
        val upperPoints = Array(numberOfDot + 1) { EPointF() }
        val bottomPoints = Array(numberOfDot + 1) { EPointF() }
    }
}
```

**EPointF.java**
```java
import androidx.annotation.NonNull;

/**
 * Created by Nhien Nguyen on 5/11/2022
 */
public class EPointF {

    public float x;
    public float y;

    public EPointF(){};

    public EPointF(final float x, final float y) {
        this.x = x;
        this.y = y;
    }

    public void setX(float x) {
        this.x = x;
    }

    public void setY(float y) {
        this.y = y;
    }

    public void setCoordinate(float x, float y){
        this.x = x;
        this.y = y;
    }

    public float getX() {
        return x;
    }

    public float getY() {
        return y;
    }

    public EPointF plus(float factor, EPointF ePointF) {
        return new EPointF(x + factor * ePointF.x, y + factor * ePointF.y);
    }

    public EPointF plus(EPointF ePointF) {
        return plus(1.0f, ePointF);
    }

    public EPointF minus(float factor, EPointF ePointF) {
        return new EPointF(x - factor * ePointF.x, y - factor * ePointF.y);
    }

    public EPointF minus(EPointF ePointF) {
        return minus(1.0f, ePointF);
    }

    public EPointF scaleBy(float factor) {
        return new EPointF(factor * x, factor * y);
    }

    public float distanceFrom(EPointF target){
        float distanceX = this.x - target.x;
        float distanceY = this.y - target.y;
        return (float) Math.hypot(distanceX, distanceY);
    }

    @NonNull
    @Override
    public String toString() {
        return "(" + x +", " + y + ")";
    }
}
```
**Change FFTCircleLine.kt to CircleLineAnimation.kt**
```kotlin
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import com.annhienktuit.muzikv2.R
import com.annhienktuit.muzikv2.enums.Interpolation
import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.painters.Painter
import com.annhienktuit.muzikv2.utils.VisualizerHelper
import com.annhienktuit.muzikv2.utils.maths.polynomials.PolynomialSplineFunction
import java.lang.Integer.min
import java.util.*
import kotlin.math.PI

/**
 *Created by Nhien Nguyen on 8/19/2022
 */

class CircleLineAnimation(
    var context: Context,
    override var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG),
    private var startHz: Int = 20,
    private var endHz: Int = 2000,
    private var interpolator: Interpolation = Interpolation.SPLINE,
) : Painter() {
    private var gravityPoints = Array(0) { GravityModel(0f) }
    private val barWidth =
        context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width).toFloat()
    private val radiusR: Float = .4f
    private val ampR: Float = 1f //The rate of amplification
    private val angleOffset = 2 * PI.toFloat() / numberOfBar
    private var shortest = 0F
    override var discRadius = 0
        set(value) {
            field = value
            shortest = getShortest()
            adjustRadius(shortest)
        }
    override var isPlaying = false
    var firstTimeRunning = true
    var curveUtil: PolynomialSplineFunction? = null
    lateinit var fft: FloatArray

    // Fields for normalization
    private var min = 0f
    private var max = 0f
    private val highFreqDelta = 20
    private var frequencyMap = HashMap<Int, Int>()
    private var random = Random()

    // Fields for draw
    private var start = Pair(0F, 0F)
    private var stop = Pair(0F, 0F)
    private var currentHeight: Int = 0
    private var currentWidth: Int = 0

    init {
        paint.apply {
            color = Color.WHITE
            style = Paint.Style.FILL
            strokeWidth = barWidth
            strokeCap = Paint.Cap.ROUND
        }
    }

    /**
     * Retrieve the [fft] data from [VisualizerHelper]
     * Then do get [getCircleFft] to connect the data
     * Then normalize the data using min-max formula
     * Put the [fft] to [Painter.GravityModel]
     * Finally, get [PolynomialSplineFunction] using [interpolateFftCircle]
     */
    override fun calc(helper: VisualizerHelper) {
        fft = helper.getFftMagnitudeRange(startHz, endHz)
        fft = getPowerFft(fft)
        fft = if (firstTimeRunning) {
            getCircleFft(fft, true)
        } else {
            getCircleFft(fft)
        }
        minMaxNormalize(fft, visualizerMaxHeight, 0)
        //Need to be tested more
//        if (isSilence(fft)) {
//            suppressLowValue(fft)
//        }
        if (gravityPoints.size != fft.size) gravityPoints = Array(fft.size) { GravityModel(0f) }
        gravityPoints.forEachIndexed { index, bar -> bar.update(fft[index] * ampR) }
        curveUtil = interpolateFftCircle(gravityPoints, numberOfBar, interpolator)
    }

    /**
     * Apply min-max normalization then handle if its value is "high frequency" continuously
     * @see <a href="https://towardsdatascience.com/everything-you-need-to-know-about-min-max-normalization-in-python-b79592732b79">Min-Max Normalization </a> <br>
     * <p> A data is defined to be "high frequency" if its value is in range <br> [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]] </p>
     */
    private fun minMaxNormalize(fftData: FloatArray, maxLimit: Int, minLimit: Int = 0) {
        min = Float.MAX_VALUE
        max = Float.MIN_VALUE
        for (value in fftData) {
            if (value > max) max = value
            if (value < min) min = value
        }
        if (max > visualizerMaxHeight) {
            for (idx in fftData.indices) {
                fftData[idx] = ((fftData[idx] - min) / (max - min)) * maxLimit + minLimit
                if (fftData[idx].isExclusive(
                        visualizerMaxHeight - highFreqDelta,
                        visualizerMaxHeight
                    )
                ) {
                    handleHighFreqValue(idx)
                } else {
                    resetMapValue(idx)
                }
            }
        }
    }

    /**
     * If the frequency value is not continue to return high frequency, reset it counter in [frequencyMap]
     */
    private fun resetMapValue(idx: Int) {
        if (frequencyMap.containsKey(idx) && frequencyMap[idx]!! > 0) {
            frequencyMap[idx] = 0
        }
    }

    /**
     * Handle high frequency value to avoid low variability
     * If that data is in range [[visualizerMaxHeight] - [highFreqDelta] , [visualizerMaxHeight]]
     */
    private fun handleHighFreqValue(idx: Int) {
        try {
            if (frequencyMap.containsKey(idx)) {
                frequencyMap[idx] = frequencyMap[idx]!!.plus(1)
            } else {
                frequencyMap[idx] = 1
            }
            if (frequencyMap[idx]!! > 3) {
                frequencyMap[idx] = 0
                fft[idx] =
                    (random.nextInt((visualizerMaxHeight - highFreqDelta) - highFreqDelta + 1) + highFreqDelta).toFloat()
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }

    }

    override fun draw(canvas: Canvas, width: Int, height: Int, drawWithoutCalc: Boolean) {
        if (drawWithoutCalc) {
            drawHelperCircleLine(canvas, .5f, .5f) {
                // Still need to run this function to do the translate, but we don't update data on the first time
                // Otherwise, it will draw in somewhere psychically
                //canvas.drawLines(points, paint)
            }
            return
        }
        currentWidth = width
        currentHeight = height
        if (firstTimeRunning) {
            shortest = min(width, height).toFloat()
            adjustRadius(shortest)
        }
        drawHelperCircleLine(canvas, .5f, .5f) {
            if (!firstTimeRunning || !drawWithoutCalc) {
                for (i in 0 until numberOfBar) {
                    start = toCartesian(shortest / 2f * radiusR, angleOffset * i)
                    stop = toCartesian(
                        shortest / 2f * radiusR + curveUtil?.value(i.toDouble())!!.toFloat(),
                        angleOffset * i
                    )
                    if (i < 4) {
                        handleSomeBarsException()
                    }
                    points[4 * i] = start.first
                    points[4 * i + 1] = start.second
                    points[4 * i + 2] = stop.first
                    points[4 * i + 3] = stop.second
                }
            }
        }
        canvas.drawLines(points, paint)
        if (firstTimeRunning) firstTimeRunning = false
    }

    private fun handleSomeBarsException() {
        if (stop.first < start.first) stop = start
    }

    /**
     * Calculate the new radius between 2 opposite point in circle
     * Then scale [shortest] based on ratio between new radius and old radius
     */
    private fun adjustRadius(minValue: Float) {
        val firstPoint = toCartesian(minValue / 2f * radiusR, angleOffset * 0)
        val secondPoint = toCartesian(minValue / 2f * radiusR, angleOffset * (numberOfBar / 2))
        val currentRadius = (firstPoint.first - secondPoint.first) / 2
        val newRadius: Float
        if (currentRadius < discRadius) {
            newRadius = discRadius.toFloat() + discMargin
            val ratio = (newRadius / currentRadius)
            this.shortest *= ratio
        }
    }

    /**
     * @return if number is in range [[start], [stop]]
     */
    private fun Float.isExclusive(start: Int, stop: Int): Boolean {
        return (this >= start && this <= stop)
    }

    private fun getShortest(): Float = min(currentWidth, currentHeight).toFloat()

    companion object {
        private const val numberOfBar: Int = 100
        val points = FloatArray(4 * numberOfBar)
    }
}

```

**FFTVisualizer.kt**
```kotlin
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.util.Log
import android.view.View
import com.annhienktuit.muzikv2.R
import com.annhienktuit.muzikv2.ui.views.visualizer.nextgen.painters.Painter
import com.annhienktuit.muzikv2.utils.VisualizerHelper

/**
 *Created by Nhien Nguyen on 8/19/2022
 */
class FFTVisualizer : View {
    private val discMargin = context.resources.getDimensionPixelSize(R.dimen.default_disc_margin)
    private val barWidth =
        context.resources.getDimensionPixelSize(R.dimen.default_visualizer_bar_width)
    private var visualizerMaxHeight: Int =
        context.resources.getDimensionPixelSize(R.dimen.default_visualizer_max_height)

    private lateinit var painter: Painter
    private lateinit var visualizerHelper: VisualizerHelper
    private var reMeasuredFreeSpace = 0
    var visible = false
    var isPlaying = false
    var discRadius: Int = 0

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

    fun setup(visualizerHelper: VisualizerHelper, painter: Painter) {
        this.visualizerHelper = visualizerHelper
        this.painter = painter.also {
            it.visualizerMaxHeight = this.visualizerMaxHeight
        }
        this.onResume()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val size = measuredWidth.coerceAtMost(measuredHeight)
        setMeasuredDimension(size, size)
        reMeasuredFreeSpace = ((size - discRadius * 2 - discMargin * 2 - barWidth * 4) / 2)
        if (reMeasuredFreeSpace < visualizerMaxHeight) visualizerMaxHeight = reMeasuredFreeSpace
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        if (!this::visualizerHelper.isInitialized) return
        handleOnFirstRun(canvas, painter)
        if ((width > 0 && height > 0 && visible)) {
            if (isPlaying) {
                painter.calc(visualizerHelper)
                painter.draw(canvas, width, height)
                postInvalidateDelayed(REFRESH_TIME_60FPS)
            } else {
                painter.draw(canvas, width, height)
            }
        } else {
            Log.e("DrawStatus", "Cannot draw because height = $height & width = $width")
        }
    }

    private fun handleOnFirstRun(canvas: Canvas, painter: Painter) {
        when (painter) {
            is CircleLineAnimation -> {
                if (painter.curveUtil == null) {
                    //If saved data exist, draw it first
                    if (!CircleLineAnimation.points.isZeroArray()) {
                        painter.draw(canvas, width, height, true)
                    }
                    //We must run this function on 1st time to initialize curve utils
                    painter.calc(visualizerHelper)
                }
            }
            is ParticleAnimation -> {
                if (painter.upperCurveUtil == null ||
                    painter.bottomCurveUtil == null
                ) {
                    //If saved data exist, draw it first
//                    if (!ParticleAnimation.upperPoints.isZeroArray()) {
//                        painter.draw(canvas, width, height, true)
//                    }
                    //We must run this function on 1st time to initialize curve utils
                    painter.calc(visualizerHelper)
                }
            }
        }
    }

    fun setPlayingStatus(isPlaying: Boolean) {
        this.isPlaying = isPlaying
        if (isPlaying) {
            invalidate()
        }
        updatePainterPlayStatus(this.isPlaying)
    }

    /**
     * Notify new playing status to [CircleLineAnimation]
     */
    private fun updatePainterPlayStatus(playing: Boolean) {
        painter.isPlaying = playing
    }

    fun onResume() {
        visible()
        invalidate()
    }

    fun onPause() {
        invisible()
    }

    /**
     * Show the visualizer
     */
    fun visible() {
        visible = true
    }

    /**
     * Hide the visualizer
     */
    fun invisible() {
        visible = false
    }

    private fun FloatArray.isZeroArray(): Boolean {
        for (idx in 0 until this.size) {
            if (this[idx] != 0F) return false
        }
        return true
    }

    companion object {
        private const val REFRESH_TIME_60FPS: Long = 1000 / 60
    }
}
```

**Painter.kt**
```kotlin
/**
 *Created by Nhien Nguyen on 8/19/2022
 */
import android.graphics.Canvas
import android.graphics.Paint
import android.util.Log
import com.annhienktuit.domain.models.EPointF
import com.annhienktuit.muzikv2.enums.Interpolation
import com.annhienktuit.muzikv2.utils.VisualizerHelper
import com.annhienktuit.muzikv2.utils.maths.interpolation.AkimaSplineInterpolator
import com.annhienktuit.muzikv2.utils.maths.interpolation.LinearInterpolator
import com.annhienktuit.muzikv2.utils.maths.polynomials.PolynomialSplineFunction
import java.util.*
import kotlin.math.cos
import kotlin.math.sin

abstract class Painter {

    open var visualizerMaxHeight = 0
    open var discRadius = 0
    open var discMargin = 0
    open var isPlaying = false
    abstract var paint: Paint
    private val li = LinearInterpolator()
    private val sp = AkimaSplineInterpolator()
    private val quietThreshold = 5f
    private var patched: FloatArray = FloatArray(0)
    private var psf: PolynomialSplineFunction? = null
    private var nRaw: Int = 0
    private var xRaw = DoubleArray(0)
    private var yRaw = DoubleArray(0)

    /**
     * An abstract function that every painters must implement and do their calculation there.
     *
     * @param helper the visualizerHelper from VisualizerView
     */
    abstract fun calc(helper: VisualizerHelper)

    /**
     * An abstract function that every painters must implement and do their drawing there.
     *
     * @param canvas the canvas from VisualizerView
     */
    abstract fun draw(canvas: Canvas, width: Int = 0, height: Int = 0, drawWithoutCalc: Boolean = false)

    /**
     * Interpolate FFT spectrum
     *
     * Android don't capture a high resolution spectrum, and we want the number of bands be adjustable,
     * so we do an interpolation here.
     *
     * (For example, to show 64 bands at frequencies from 0Hz to 1200Hz, Android only return ~10 FFT values.
     * So we need to interpolate those ~10 values into 64 values in order to fit it into our bands)
     *
     * @param gravityModels Array of gravityModel
     * @param sliceNum Number of Slice
     * @param interpolator Which interpolator to use, `li` for Linear, `sp` for Spline
     *
     * @return a `PolynomialSplineFunction` (psf). To get the value, use
     * `psf.value(x)`, where `x` must be a Double value from 0 to `num`
     */
    fun interpolateFft(
        gravityModels: Array<GravityModel>, sliceNum: Int, interpolator: Interpolation
    ): PolynomialSplineFunction? {
        if (gravityModels.size != nRaw) {
            nRaw = gravityModels.size
            xRaw = DoubleArray(nRaw) { (it * sliceNum).toDouble() / (nRaw - 1) }
            yRaw = DoubleArray(nRaw)
        } else {
            xRaw.forEach {
                (it * sliceNum) / (nRaw - 1)
            }
        }
        gravityModels.forEachIndexed { index, bar -> yRaw[index] = bar.height.toDouble() }
        psf = when (interpolator) {
            Interpolation.LINEAR -> li.interpolate(xRaw, yRaw)
            Interpolation.SPLINE -> sp.interpolate(xRaw, yRaw)
        }
        return psf
    }

    /**
     * Interpolate FFT spectrum (Circle)
     *
     * Similar to `interpolateFft()`. However this is meant for Fft from `getCircleFft()`
     *
     * @param gravityModels Array of gravityModel
     * @param sliceNum Number of Slice
     * @param interpolator Which interpolator to use, `li` for Linear, `sp` for Spline
     *
     * @return a `PolynomialSplineFunction` (psf). To get the value, use
     * `psf.value(x)`, where `x` must be a Double value from 0 to `num`
     */
    fun interpolateFftCircle(
        gravityModels: Array<GravityModel>, sliceNum: Int, interpolator: Interpolation
    ): PolynomialSplineFunction? {
        if (gravityModels.size != nRaw) {
            nRaw = gravityModels.size
            xRaw = DoubleArray(nRaw) { ((it - 1) * sliceNum).toDouble() / (nRaw - 1 - 2) }
            yRaw = DoubleArray(nRaw)
        } else {
            xRaw.forEach {
                ((it - 1) * sliceNum) / (nRaw - 1 - 2)
            }
        }
        gravityModels.forEachIndexed { index, bar -> yRaw[index] = bar.height.toDouble() }
        psf = when (interpolator) {
            Interpolation.LINEAR -> li.interpolate(xRaw, yRaw)
            Interpolation.SPLINE -> sp.interpolate(xRaw, yRaw)
        }
        return psf
    }

    /**
     * Check if it's quiet enough such that we can skip the drawing
     * @param fft Fft
     * @return true if it's quiet, false otherwise
     */
    fun isQuiet(fft: FloatArray): Boolean {
        fft.forEach { if (it > quietThreshold) return false }
        Log.i("IsQuiet", "True")
        return true
    }

    /**
     * Check if it's silence enough?
     * If true, we suppress all to 0 for easier visualizer
     */
    fun isSilence(fft: FloatArray): Boolean {
        if(fft.isEmpty()) return false
        if(getAverage(fft) > LOW_VALUE_THRESHOLD) return false
        return true
    }

    private fun getAverage(fft: FloatArray):Float {
        var sum = 0F
        for(idx in fft.indices){
            sum += fft[idx]
        }
        return sum / fft.size
    }

    fun suppressLowValue(fft: FloatArray){
        Arrays.fill(fft, 0F)
    }

    /**
     * Convert Polar to Cartesian
     * @param radius Radius
     * @param theta Theta
     * @return Pair of (x,y) of Cartesian
     */
    fun toCartesian(radius: Float, theta: Float): Pair<Float, Float> {
        return Pair(radius * cos(theta), radius * sin(theta))
    }

    /**
     * Convert Polar to Cartesian
     * @param radius Radius
     * @param theta Theta
     * @return [EPointF] of (x,y) of Cartesian
     */
    fun toCartesianEpointF(radius: Float, theta: Float): EPointF{
        return EPointF(radius * cos(theta), radius * sin(theta))
    }

    /**
     * Patch the Fft so that the start and the end connect perfectly. Use this with `interpolateFftCircle()`
     *
     * `[0, 1, ..., n] -> [n-1, 0, 1, ..., n-1, 0, 1]`
     *
     * @param fft Fft
     * @return CircleFft
     */
    fun getCircleFft(fft: FloatArray, updateArraySize: Boolean = false): FloatArray {
        if (updateArraySize) {
            patched = FloatArray(fft.size + 2)
        }
        fft.forEachIndexed { index, item -> patched[index + 1] = item }
        patched[0] = fft[fft.lastIndex - 1]
        patched[patched.lastIndex - 1] = fft[0]
        patched[patched.lastIndex] = fft[1]
        return patched
    }

    /**
     * Boost high values while suppress low values, generally give a powerful feeling
     * @param fft Fft
     * @param power Parameter, adjust to fit your liking
     * @return PowerFft
     */
    fun getPowerFft(fft: FloatArray, power: Float = 100.0F): FloatArray {
        return fft.map { it * it / power }.toFloatArray()
    }

    /**
     * Draw the circle visualizer
     * @param xR The ratio of X position to the canvas.width (= the width of the visualizer's view)
     * For example, 0f mean the start/left, while 1f mean the end/right of the screen.
     * @param yR The ratio of Y position to the canvas.height (= the height of the visualizer's view)
     * For example, 0f mean the top, while 1f mean the bottom of the screen.
     */
    fun drawHelperCircleLine(
        canvas: Canvas, xR: Float, yR: Float, drawCircleLine: () -> Unit
    ) {
        canvas.save()
        canvas.translate(canvas.width * xR, canvas.height * yR)
        drawCircleLine()
    }

    /**
     * A model with gravity. Useful to smooth raw Fft values.
     */
    class GravityModel(
        var height: Float = 0f,
        var dy: Float = 0f,
        var ay: Float = 2f
    ) {
        fun update(h: Float) {
            if (h > height) {
                height = h
                dy = 0f
            }
            height -= dy
            dy += ay
            if (height < 0) {
                height = 0f
                dy = 0f
            }
        }

        override fun toString(): String {
            return "@GravityModel[Height: $height, dy: $dy, ay: $ay]"
        }
    }

    companion object{
        private const val LOW_VALUE_THRESHOLD = 7F
    }
}
```

**VisualierHelper.kt**
```kotlin
import com.annhienktuit.muzikv2.processors.FFTAudioProcessor
import kotlin.math.hypot

/**
 *Created by Nhien Nguyen on 8/19/2022
 */
class VisualizerHelper {

    private var fftM: FloatArray
    private var fftBuffer: FloatArray
    private var listener: FFTAudioProcessor.FFTListener
    private var magnitudeIndex: Int = 0
    private lateinit var audioProcessor: FFTAudioProcessor

    init {
        fftBuffer = FloatArray(FFTAudioProcessor.SAMPLE_SIZE + 2)
        fftM = FloatArray(fftBuffer.size / 2 - 1)
        listener =
            FFTAudioProcessor.FFTListener { sampleRateHz, channelCount, fft -> fftBuffer = fft }
    }

    fun setAudioProcessor(processor: FFTAudioProcessor) {
        this.audioProcessor = processor
        processor.listener = listener
    }

    /**
     * Get the FFT Magnitude
     */
    private fun getFftMagnitude(): FloatArray {
        for (k in fftM.indices) {
            magnitudeIndex = (k + 1) * 2
            fftM[k] = hypot(fftBuffer[magnitudeIndex], fftBuffer[magnitudeIndex + 1])
        }
        return fftM
    }

    /**
     * Get Fft values from startHz to endHz
     */
    fun getFftMagnitudeRange(startHz: Int, endHz: Int): FloatArray {
        return getFftMagnitude().copyOfRange(hzToFftIndex(startHz), hzToFftIndex(endHz))
    }

    /**
     * Equation from documentation, kth frequency = k*Fs/(n/2)
     */
    private fun hzToFftIndex(Hz: Int): Int {
        return (Hz * 1024 / (44100 * 2)).coerceAtLeast(0).coerceAtMost(255)
    }

    /**
     * Release visualizer when not using anymore
     */
    fun release() {
        fftM = emptyArray<Float>().toFloatArray()
        fftBuffer = emptyArray<Float>().toFloatArray()
    }

}
```

**PolyBezierPathUtil.java**
```java

import android.graphics.Path;

import com.annhienktuit.domain.models.EPointF;

import java.util.Collection;
import java.util.List;

public class PolyBezierPathUtil {

    /**
     * Computes a Poly-Bezier curve passing through a given list of knots.
     * The curve will be twice-differentiable everywhere and satisfy natural
     * boundary conditions at both ends.
     *
     * @param knots a list of knots
     * @return      a Path representing the twice-differentiable curve
     *              passing through all the given knots
     */
    public Path computePathThroughKnots(List<EPointF> knots) {
        throwExceptionIfInputIsInvalid(knots);

        final Path polyBezierPath = new Path();
        final EPointF firstKnot = knots.get(0);
        polyBezierPath.moveTo(firstKnot.getX(), firstKnot.getY());

        /*
         * variable representing the number of Bezier curves we will join
         * together
         */
        final int n = knots.size() - 1;

        if (n == 1) {
            final EPointF lastKnot = knots.get(1);
            polyBezierPath.lineTo(lastKnot.getX(), lastKnot.getY());
        } else {
            final EPointF[] controlPoints = computeControlPoints(n, knots);

            for (int i = 0; i < n; i++) {
                final EPointF targetKnot = knots.get(i + 1);
                appendCurveToPath(polyBezierPath, controlPoints[i], controlPoints[n + i], targetKnot);
            }
        }

        return polyBezierPath;
    }

    private EPointF[] computeControlPoints(int n, List<EPointF> knots) {
        final EPointF[] result = new EPointF[2 * n];

        final EPointF[] target = constructTargetVector(n, knots);
        final Float[] lowerDiag = constructLowerDiagonalVector(n - 1);
        final Float[] mainDiag = constructMainDiagonalVector(n);
        final Float[] upperDiag = constructUpperDiagonalVector(n - 1);

        final EPointF[] newTarget = new EPointF[n];
        final Float[] newUpperDiag = new Float[n - 1];

        // forward sweep for control points c_i,0:
        newUpperDiag[0] = upperDiag[0] / mainDiag[0];
        newTarget[0] = target[0].scaleBy(1 / mainDiag[0]);

        for (int i = 1; i < n - 1; i++) {
            newUpperDiag[i] = upperDiag[i] /
                    (mainDiag[i] - lowerDiag[i - 1] * newUpperDiag[i - 1]);
        }

        for (int i = 1; i < n; i++) {
            final float targetScale = 1 /
                    (mainDiag[i] - lowerDiag[i - 1] * newUpperDiag[i - 1]);

            newTarget[i] =
                    (target[i].minus(newTarget[i - 1].scaleBy(lowerDiag[i - 1]))).scaleBy(targetScale);
        }

        // backward sweep for control points c_i,0:
        result[n - 1] = newTarget[n - 1];

        for (int i = n - 2; i >= 0; i--) {
            result[i] = newTarget[i].minus(newUpperDiag[i], result[i + 1]);
        }

        // calculate remaining control points c_i,1 directly:
        for (int i = 0; i < n - 1; i++) {
            result[n + i] = knots.get(i + 1).scaleBy(2).minus(result[i + 1]);
        }

        result[2 * n - 1] = knots.get(n).plus(result[n - 1]).scaleBy(0.5f);

        return result;
    }

    private EPointF[] constructTargetVector(int n, List<EPointF> knots) {
        final EPointF[] result = new EPointF[n];

        result[0] = knots.get(0).plus(2, knots.get(1));

        for (int i = 1; i < n - 1; i++) {
            result[i] = (knots.get(i).scaleBy(2).plus(knots.get(i + 1))).scaleBy(2);
        }

        result[result.length - 1] = knots.get(n - 1).scaleBy(8).plus(knots.get(n));

        return result;
    }

    private Float[] constructLowerDiagonalVector(int length) {
        final Float[] result = new Float[length];

        for (int i = 0; i < result.length - 1; i++) {
            result[i] = 1f;
        }

        result[result.length - 1] = 2f;

        return result;
    }

    private Float[] constructMainDiagonalVector(int n) {
        final Float[] result = new Float[n];

        result[0] = 2f;

        for (int i = 1; i < result.length - 1; i++) {
            result[i] = 4f;
        }

        result[result.length - 1] = 7f;

        return result;
    }

    private Float[] constructUpperDiagonalVector(int length) {
        final Float[] result = new Float[length];

        for (int i = 0; i < result.length; i++) {
            result[i] = 1f;
        }

        return result;
    }

    private void appendCurveToPath(Path path, EPointF control1, EPointF control2, EPointF targetKnot) {
        path.cubicTo(
                control1.getX(),
                control1.getY(),
                control2.getX(),
                control2.getY(),
                targetKnot.getX(),
                targetKnot.getY()
        );
    }

    private void throwExceptionIfInputIsInvalid(Collection<EPointF> knots) {
        if (knots.size() < 2) {
            throw new IllegalArgumentException(
                    "Collection must contain at least two knots"
            );
        }
    }

}
```