# PARTICLE ANIMATION - 29/09
**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 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()
private var actualBarHeight = 0F
private var scale = 1F
//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.parseColor(visualizerColor)
style = Paint.Style.FILL
strokeWidth = barWidth
strokeCap = Paint.Cap.ROUND
alpha = 200
}
bottomLinePaint.apply {
color = Color.parseColor(visualizerColor)
style = Paint.Style.STROKE
strokeWidth = barWidth / 2
strokeCap = Paint.Cap.ROUND
alpha = 200
}
spearPaint.apply {
color = Color.parseColor(visualizerColor)
style = Paint.Style.FILL
strokeWidth = barWidth / 2
strokeCap = Paint.Cap.ROUND
alpha = 30
}
}
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 / 1.5F)
}
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
}
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.35f * (radiusR + 0.03F) + bottomCurveUtil?.value(idx.toDouble())!!
.toFloat(), angleOffset * idx
)
}
}
canvas.drawCircle(0f, 0f, 10f, paint)
}
upperPoints[numberOfDot] = upperPoints[0]
bottomPoints[numberOfDot] = bottomPoints[0] // we do this to connect the begin-end of line
makeSureViewNotOutOfBound()
for (idx in upperPoints.indices) {
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
)
}
}
}
/**
* Check whether the current bar is over [visualizerMaxHeight]
* If true, scale tp fit [visualizerMaxHeight]
*/
private fun makeSureViewNotOutOfBound() {
for (idx in upperPoints.indices) {
actualBarHeight = (upperPoints[idx].distanceFrom(centerPoint) - discMargin - discRadius)
if (actualBarHeight > visualizerMaxHeight) {
scale = visualizerMaxHeight / actualBarHeight
upperPoints[idx] = upperPoints[idx].scaleBy(scale)
}
}
}
/**
* 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()
companion object {
private const val TAG = "ParticleAnimation"
private const val numberOfDot: Int = 100
private const val drawSpearThreshold = 20F
private const val visualizerColor = "#FFFFFF"
val upperPoints = Array(numberOfDot + 1) { EPointF() }
val bottomPoints = Array(numberOfDot + 1) { EPointF() }
val centerPoint = EPointF(0F, 0F)
}
}
```